Skip to content

feat(record-tour): save and load tour setup as a JSON file - #898

Merged
giswqs merged 8 commits into
mainfrom
fix/issue-897-tour-save-load
Jun 25, 2026
Merged

feat(record-tour): save and load tour setup as a JSON file#898
giswqs merged 8 commits into
mainfrom
fix/issue-897-tour-save-load

Conversation

@giswqs

@giswqs giswqs commented Jun 25, 2026

Copy link
Copy Markdown
Member

Summary

The Record Map Tour panel could only export the finished video. The underlying tour data (keyframes, per-segment transition durations, frame rate) lived only in component state and was lost when the panel closed, so a tour could not be paused, refined, or reused in a later session. This implements the save/load requested in #897.

What changed

  • Save setup and Load setup buttons near the top of the panel.
    • Save serializes the keyframes and FPS to a pretty-printed .json file (via the existing saveTextFileWithFallback, so it works in both the desktop save dialog and the browser download/File System Access fallback).
    • Load reads a saved file back, replacing the keyframe list and frame rate. Fresh keyframe ids are minted on load so reloaded rows never collide. After loading, the user keeps full control to add views, reorder, and adjust before recording.
  • A geolibre-tour marker + schema version in the file, with a parser that validates structure, requires at least one keyframe, and clamps the frame rate and every segment duration into the same range the controls enforce (so a hand-edited or stale file is never out of range). A malformed file shows a translated error rather than crashing.
  • The FPS / segment-duration bounds moved to tour-recorder.ts as exported constants so the UI and the parser share one source of truth.
  • New i18n strings under recordTour (en.json).
  • Unit tests for serializeTourConfig / parseTourConfig (round-trip, clamping, and rejection of malformed input).

Verification

  • npm run build and node --test tests/tour-recorder.test.ts pass; pre-commit clean on the changed files.
  • Drove the real app with Playwright: added two keyframes, set FPS to 24, saved the setup (correct JSON downloaded), reloaded to an empty panel, and loaded the file back (keyframes and FPS restored, "Loaded setup with 2 keyframes." banner). Confirmed the panel and the new buttons render correctly in both light and dark themes.

Closes #897

Summary by CodeRabbit

  • New Features
    • Added “Load setup” and “Save setup” actions to export/import tour keyframes and FPS via a separate JSON file.
    • Added dedicated setup feedback messages, including a confirmation prompt when loading an existing setup.
  • Bug Fixes
    • Improved setup validation and normalization on import, including regenerated keyframe identifiers and camera/segment value clamping.
    • Invalid or out-of-range setup files now fail with clearer, user-facing error messages.
  • Tests
    • Added comprehensive tests for tour setup serialization/parsing, clamping behavior, and error handling for malformed or unsupported files.

The Record Map Tour panel could only export the recorded video; the
underlying keyframes, durations, and frame rate were lost when the panel
closed, so a tour could not be paused, refined, or reused.

Add a Save setup / Load setup pair near the top of the panel. Save writes
the keyframes and FPS to a JSON file; Load reads one back, repopulating the
keyframe list and frame rate (fresh ids are minted so reloaded rows never
collide). The serializer and parser share the FPS/segment bounds with the
controls, so a hand-edited or stale file is clamped to the supported range,
and a malformed file surfaces a translated error instead of crashing.

Closes #897
@netlify

netlify Bot commented Jun 25, 2026

Copy link
Copy Markdown

Deploy Preview for geolibre-app ready!

Name Link
🔨 Latest commit d52671f
🔍 Latest deploy log https://app.netlify.com/projects/geolibre-app/deploys/6a3db67ba8927700080e900e
😎 Deploy Preview https://deploy-preview-898--geolibre-app.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@giswqs, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 18 minutes and 7 seconds. Learn how PR review limits work.

To continue reviewing without waiting, enable usage-based billing in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c8f3aeba-881c-4b92-b092-39c0b660587d

📥 Commits

Reviewing files that changed from the base of the PR and between 756276d and d52671f.

📒 Files selected for processing (4)
  • apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
  • apps/geolibre-desktop/src/lib/tauri-io.ts
  • apps/geolibre-desktop/src/lib/tour-recorder.ts
  • tests/tour-recorder.test.ts
📝 Walkthrough

Walkthrough

RecordTourDialog now saves and loads tour configuration JSON separately from tour recording output. The recorder library adds config serialization/parsing and validation, with updated dialog copy and tests for the new save/load flow.

Changes

Tour configuration save/load

Layer / File(s) Summary
Config format and parser
apps/geolibre-desktop/src/lib/tour-recorder.ts, tests/tour-recorder.test.ts
Adds config bounds plus JSON serialize/parse helpers for tour keyframes and FPS, with tests covering round-tripping, clamping, and invalid inputs.
Dialog save/load wiring
apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx, apps/geolibre-desktop/src/i18n/locales/en.json
Adds config save/load actions in the dialog, updates tour state from parsed configs, and renders the new recordTour strings and status banner.

Sequence Diagram(s)

sequenceDiagram
  participant RecordTourDialog
  participant tour_recorder.ts
  participant saveTextFileWithFallback
  participant openLocalDataFileWithFallback

  RecordTourDialog->>tour_recorder.ts: serializeTourConfig(keyframes, fps)
  RecordTourDialog->>saveTextFileWithFallback: write config JSON
  RecordTourDialog->>openLocalDataFileWithFallback: choose config file
  openLocalDataFileWithFallback-->>RecordTourDialog: file text
  RecordTourDialog->>tour_recorder.ts: parseTourConfig(text)
  tour_recorder.ts-->>RecordTourDialog: parsed keyframes and fps
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • opengeos/GeoLibre#822: Related through the same RecordTourDialog and tour-recorder flow that this PR extends with configuration save/load support.

Poem

🐇 I packed the tour in JSON neat,
With keyframes hopping on tidy feet.
Save, then load, and off we go,
FPS and paths in one bright flow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: saving and loading the Record Tour setup as JSON.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-897-tour-save-load

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

⚡ Cloudflare Pages preview

Item Value
Preview https://e96e8ad9.geolibre-preview.pages.dev
Demo app https://e96e8ad9.geolibre-preview.pages.dev/demo/
Commit 8f6756a

Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts
Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/i18n/locales/en.json Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Reviewed tour-recorder.ts, RecordTourDialog.tsx, en.json, and tests/tour-recorder.test.ts, plus tauri-io.ts for context on the file I/O layer. The overall design is solid: constants promoted to shared exports, a dedicated parse/serialize layer, clamping on the fields that map to UI controls, and a good unit-test suite covering round-trip, clamping, and rejection cases.

Four findings below, from most to least significant.


Bugs / Logic

  • Schema version not validated (tour-recorder.ts L198) — parseTourConfig writes version: 1 but never reads obj.version back. A future breaking format change would be silently accepted by old parsers and produce wrong results. Suggest adding a version > TOUR_CONFIG_VERSION rejection. Confidence: medium.

  • zoom, pitch, bearing not clamped (tour-recorder.ts L169-174) — The PR description and JSDoc say the parser clamps loaded values to the range the UI controls enforce, but only fps and durationMs are actually clamped. zoom/pitch/bearing fall back to 0 for non-finite input but otherwise pass through unchecked. MapLibre handles out-of-range values gracefully internally, so no crash today, but the stated contract isn't met. Confidence: medium.

Quality

  • DEFAULT_CONFIG_FILE_NAME === DEFAULT_FILE_NAME (RecordTourDialog.tsx L52-54) — Both are "map-tour"; the comment says they're "kept distinct … so the two exports don't collide" but the files only differ by extension (.json vs .webm). Either rename the config constant (e.g. "map-tour-setup") or drop the misleading comment. Confidence: high.

CLAUDE.md (i18n)

  • Grammar in configSaved string (en.json L485) — "Saved setup {{name}}" is missing a preposition; "Saved setup as {{name}}" reads more naturally. Confidence: high.

No security issues found. The JSON parsing is properly guarded with try/catch and Number.isFinite checks; all values that reach the renderer are numbers, not raw strings. File I/O goes through the same openLocalDataFileWithFallback / saveTextFileWithFallback paths used elsewhere in the codebase. The test suite covers the main validation paths (round-trip, clamping, rejection of bad input) and the new i18n strings follow the pluralization convention already in use.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/lib/tour-recorder.ts`:
- Around line 185-208: The parseTourConfig function currently validates type and
keyframes but ignores the serialized version field, so add a version check
before parsing the rest of the payload. In parseTourConfig, verify obj.version
against TOUR_CONFIG_VERSION and throw the same kind of user-facing error used
for other invalid tour config cases when it does not match, so future
incompatible formats fail fast instead of being parsed incorrectly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 32c8cae5-f881-45a7-b535-f695a9bac44c

📥 Commits

Reviewing files that changed from the base of the PR and between 9a9f62c and 124486d.

📒 Files selected for processing (4)
  • apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
  • apps/geolibre-desktop/src/i18n/locales/en.json
  • apps/geolibre-desktop/src/lib/tour-recorder.ts
  • tests/tour-recorder.test.ts

Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts
- parseTourConfig now rejects a config written by a newer, incompatible
  format version (was stamped on save but never read back); a missing or
  older version is still accepted.
- Clamp zoom/pitch/bearing into MapLibre's supported ranges on load to match
  the documented validation contract (only durationMs/fps were clamped).
- Rename the config file default to "map-tour-setup" so it is genuinely
  distinct from the video name, and fix the misleading comment.
- Grammar: "Saved setup as {{name}}".
- Add tests for camera clamping and newer-version rejection.
Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts Outdated
Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts
Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Overall this is a clean, well-tested addition. The serialization/parsing logic is solid, the constants are properly centralised, and the i18n integration is correct. The four findings below are the things I would address before merging.


Bugs

# Finding Confidence
1 Bearing clamping vs wrappingparseKeyframe clamps bearing to [-180, 180] but the comment says it "wraps". A hand-edited bearing: 270 (west) becomes 180 (south) instead of -90 (west). The fix is to normalise with modular arithmetic rather than clamp. The existing unit test for bearing: 999 → 180 also confirms the wrong behaviour. High
2 No latitude bounds on center — center coordinates are only checked to be finite numbers; an out-of-range latitude (e.g. center: [0, 999]) silently passes validation. MapLibre's Mercator projection clips past ~±85.05°, potentially producing a silently wrong camera position. Adding Math.abs(center[1]) > 90 to the guard would be consistent with the clamping already applied to zoom/pitch/bearing. Medium

Security / Performance

# Finding Confidence
3 Unbounded keyframe arrayparseTourConfig does not cap obj.keyframes.length. A crafted file with hundreds of thousands of entries would allocate a large array and trigger a createId() loop in the component, causing noticeable latency or a tab stall. A simple upper-bound check (e.g. 500) would prevent this. Medium

Quality / UX

# Finding Confidence
4 clearResultMessages() fires on picker cancel — in handleLoadConfig, clearResultMessages() is called before the file picker opens. If the user opens the picker and then cancels, the result is null and the function returns — but any previously shown success banner ("Saved setup as …") has already been erased. Moving clearResultMessages() to just after the if (!result?.text) return guard fixes this with one-line change. High
5 Silent keyframe replacement — clicking "Load setup" when existing keyframes are present immediately discards them with no confirmation. A single misclick destroys work that cannot be recovered. A window.confirm guard (or at minimum a tooltip warning) would be standard UX for a destructive replace action. Medium

CLAUDE.md

No violations. New user-facing strings are correctly behind t(), constants are shared from a single source of truth, and the store-first data flow is preserved.

- Wrap bearing onto (-180, 180] instead of clamping, so a hand-edited 270
  maps to -90 (west) rather than 180 (south).
- Reject a keyframe whose latitude is outside ±90 (a real out-of-range
  coordinate), matching the validation the comment claims.
- Cap parsed keyframes at 500 so a crafted/huge file can't make the parser
  allocate a giant array and loop createId() over it.
- handleLoadConfig: only clear the result banner once a file is actually
  chosen, so cancelling the picker no longer wipes a prior "Saved setup…".
- Confirm before loading when the panel already has keyframes, so a misclick
  on "Load setup" can't silently discard in-progress work (new confirmLoad
  string).
- Tests for bearing wrap, latitude rejection, and the keyframe cap.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx (1)

378-396: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve banners when config save is canceled.

Line 380 clears the current result before the save dialog resolves. If the user cancels, name is null and the prior “saved/loaded” message is lost even though nothing changed.

Suggested fix
   const handleSaveConfig = async () => {
     if (keyframes.length === 0) return;
-    clearResultMessages();
     try {
       const content = serializeTourConfig(keyframes, fps);
       const fileType = t("recordTour.configFileType");
       const name = await saveTextFileWithFallback(content, {
@@
         ],
         mimeType: "application/json",
       });
-      if (name) setConfigMessage(t("recordTour.configSaved", { name }));
+      if (!name) return;
+      clearResultMessages();
+      setConfigMessage(t("recordTour.configSaved", { name }));
     } catch (err) {
       console.warn("Tour configuration save failed", err);
+      clearResultMessages();
       setError(t("recordTour.configSaveError"));
     }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx` around
lines 378 - 396, In handleSaveConfig, avoid clearing the current result message
before the save dialog outcome is known, because canceling the dialog currently
wipes the existing banner even though no config change occurred. Move
clearResultMessages() so it only runs after saveTextFileWithFallback returns a
real filename, or otherwise restore the previous message when name is null; keep
the behavior scoped to RecordTourDialog’s handleSaveConfig, setConfigMessage,
and saveTextFileWithFallback flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/lib/tour-recorder.ts`:
- Around line 51-55: The MAX_KEYFRAMES guard in tour-recorder.ts is applied
after JSON.parse, so large config text can still be fully allocated before
rejection. Add an upfront text-length guard in the config import/parsing flow
before calling JSON.parse, and keep the existing MAX_KEYFRAMES validation
afterward; use the relevant parsing/import path around the keyframes handling so
the limit protects the parser as well as the later loop that mints ids.

---

Outside diff comments:
In `@apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx`:
- Around line 378-396: In handleSaveConfig, avoid clearing the current result
message before the save dialog outcome is known, because canceling the dialog
currently wipes the existing banner even though no config change occurred. Move
clearResultMessages() so it only runs after saveTextFileWithFallback returns a
real filename, or otherwise restore the previous message when name is null; keep
the behavior scoped to RecordTourDialog’s handleSaveConfig, setConfigMessage,
and saveTextFileWithFallback flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 57d5220c-65d9-4abe-be47-f0f6c67acb6e

📥 Commits

Reviewing files that changed from the base of the PR and between 124486d and 9df89ed.

📒 Files selected for processing (4)
  • apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
  • apps/geolibre-desktop/src/i18n/locales/en.json
  • apps/geolibre-desktop/src/lib/tour-recorder.ts
  • tests/tour-recorder.test.ts

Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
- Guard the raw config text length (1 MB) before JSON.parse, so a pathological
  file is rejected without being fully allocated first (completes the
  MAX_KEYFRAMES DoS hardening, which only ran post-parse).
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

# Finding Confidence
B1 Empty file silently swallowed (RecordTourDialog.tsx:417). The guard if (!result?.text) returns early for an empty file (text === "") without showing any error. The user picks a file and gets no feedback. Changing the check to if (result == null) lets the existing parseTourConfig path surface the "not valid JSON" error. (Inline comment posted.) Medium

Quality

# Finding Confidence
Q1 Save-cancel wipes prior success message (RecordTourDialog.tsx:380). handleSaveConfig calls clearResultMessages() before opening the picker, so cancelling the dialog silently drops a previously-displayed "Saved setup as …" banner. handleLoadConfig explicitly avoids this pattern with its own comment; handleSaveConfig should follow the same design. Suggested fix in inline comment. Medium
Q2 window.confirm bypasses the design system (RecordTourDialog.tsx:405). The overwrite confirmation uses a blocking system dialog instead of the project's AlertDialog component, diverging from the rest of the UI and behaving differently in Tauri's webview. Functional today, but inconsistent. (Inline comment posted.) Low–medium
Q3 No file-size limit before JSON.parse (tour-recorder.ts:parseTourConfig). A crafted or accidentally huge file is read entirely into memory and passed to JSON.parse before MAX_KEYFRAMES is checked. This is consistent with the wider codebase's existing openLocalDataFileWithFallback callers. Worth a follow-up issue, not a blocker here. Low
Q4 configMessage has no ARIA live region (RecordTourDialog.tsx:613). The new success paragraph lacks role="status" / aria-live="polite", so screen readers may not announce it. Consistent with the pre-existing savedName paragraph — a wider gap, not introduced here. Low

Security, Performance, CLAUDE.md

Nothing significant. The parser is well-hardened: the MAX_KEYFRAMES cap, MAX_ZOOM/MAX_PITCH clamping, bearing normalisation (wrapping rather than clamping so 270 maps to −90 rather than 180), latitude bounds check, and version-gating all look correct. The TOUR_CONFIG_TYPE marker prevents accidental loading of arbitrary JSON. Constants moved to tour-recorder.ts correctly DRY up the dialog and the parser. Tests cover the key validation paths (round-trip, FPS clamping on both serialize and parse, camera bounds, version rejection, malformed input). CLAUDE.md conventions are followed (i18n via t(), new strings in en.json, no direct MapLibre mutation from UI).

- handleSaveConfig now clears the result banner only after the file is
  actually written, matching handleLoadConfig, so cancelling the save dialog
  no longer wipes a prior "Saved setup…" message.
- handleLoadConfig short-circuits only on a cancelled picker (null result);
  an empty file now flows through to parseTourConfig so it surfaces a real
  error instead of silently doing nothing.
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx Outdated
Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts Outdated
Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Overall the implementation is solid: the serialize/parse pair is well-designed with sensible limits (1 MB, 500 keyframes), the validation is thorough, bearing normalization is mathematically correct, constants are properly centralised, and the test suite covers round-trips, clamping, and rejection paths well. Three findings below, all in the newly introduced code.


Bugs

# Location Finding Confidence
1 RecordTourDialog.tsx:417 Empty file silently swallowed. !result?.text is falsy for both null (picker cancelled — intentional no-op) and "" (empty file selected). Selecting an empty file produces no error and no visual feedback; the existing banner is left unchanged and the user has no idea what happened. The fix is to distinguish result === null (cancel, keep banner) from result.text === "" (invalid file, show configLoadError). Medium

Quality / Maintainability

# Location Finding Confidence
2 tour-recorder.ts:168 Asymmetric normalization in serializeTourConfig. fps is clamped on write, but each keyframe's durationMs is passed through verbatim. The existing test actually demonstrates this gap: it feeds durationMs: 1 to serializeTourConfig, which writes 1 unchanged, and parseTourConfig then clamps it on reload. Since the UI always writes valid values this is not a live bug, but it makes the API misleading and is a footgun for future programmatic callers. Low
3 tour-recorder.ts:240 Version gate bypassed by a non-numeric version field. typeof obj.version === "number" is false for "version": "2" (string), so the check is skipped and the file is silently processed as v1. This only matters for hand-edited files; either tighten the guard or add a test case documenting the intended behaviour. Low

Security

Nothing to flag. The 1 MB text-length guard before JSON.parse is well-placed; parsed data is used only as camera numbers with no string interpolation into HTML or eval; i18n interpolation goes through react-i18next (not dangerouslySetInnerHTML).

Performance

No concerns. The size and keyframe-count limits are appropriately conservative.

CLAUDE.md

  • New i18n strings are in en.json and use t() at every callsite ✓
  • saveTextFileWithFallback is used (not a custom download path) ✓
  • No direct MapLibre mutations from the UI ✓
  • window.confirm is consistent with the existing pattern used in DesktopShell.tsx, PythonEditorPane.tsx, and StoryMapPanel.tsx

Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
Comment thread apps/geolibre-desktop/src/components/layout/RecordTourDialog.tsx
Comment thread tests/tour-recorder.test.ts
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Overall this is a clean, well-scoped addition. The parser is defensive (text-length cap, keyframe count cap, clamping, bearing normalisation, lat range check), the constants are correctly centralised, and the test suite covers the main cases. Three findings below.


Bugs

# Finding Confidence
1 Browser file-picker cancel hangs handleLoadConfigopenLocalDataFileWithFallback's browser fallback attaches only onchange to the <input type="file">, so if the user dismisses the picker without selecting a file the returned Promise never settles (Chrome 113+ / Firefox 108+ fire a cancel event that isn't handled). pickImageFilesWithFallback in the same file already shows the fix: input.addEventListener("cancel", () => resolve(null)). The callsite in this PR is correct; the patch belongs in openLocalDataFileWithFallback in tauri-io.ts. Medium

Quality

# Finding Confidence
2 Confirm-before-pick orderwindow.confirm fires before the file picker opens, so the user commits to overwriting their work before they know which file will be loaded. In Tauri, cancelling the picker after confirming is a safe no-op, but the UX is a bit backwards and interacts badly with finding #1 in the browser (confirm succeeds, picker is dismissed, Promise hangs). The conventional order is: open picker → if a valid file is ready and existing work is non-empty → confirm. Low
3 Missing fps assertion in the "wraps a 270 bearing" test — the test omits the fps key, exercising the DEFAULT_FPS fallback, but doesn't assert config.fps === 30, so a regression there would go undetected. Small addition closes the gap. Low

Security

Nothing to flag. The text-length cap (1 MB), keyframe count cap (500), input clamping, and absence of eval/innerHTML are all in good shape.

Performance

Nothing to flag. Serialising / parsing even the maximum 500-keyframe config is well within synchronous budget.

CLAUDE.md

New i18n strings are added under recordTour in en.json (the source-of-truth locale) with correct plural suffixes (_one / _other) — in line with the i18n conventions.

giswqs added 2 commits June 25, 2026 19:06
- Clamp each keyframe's durationMs on serialize too, mirroring parseKeyframe,
  so save/load is symmetric and a programmatic caller can't persist an
  out-of-range duration.
- Tighten the version gate to also reject a present-but-non-numeric version
  (e.g. "2"); a missing version is still accepted as legacy v1. Add tests for
  the string-version rejection and the missing-version acceptance.
- Fix openLocalDataFileWithFallback hanging forever when the browser file
  picker is dismissed without a selection: the input only had an onchange
  handler (which never fires on cancel), so handleLoadConfig's await never
  settled. Add a "cancel" listener that resolves null, matching the existing
  pickImageFilesWithFallback pattern.
- Assert the missing-fps fallback to DEFAULT_FPS in the parse test.
Comment thread apps/geolibre-desktop/src/lib/tour-recorder.ts
): string {
const config: TourConfig = {
type: TOUR_CONFIG_TYPE,
version: TOUR_CONFIG_VERSION,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (low confidence): camera fields are not normalized on save, creating a subtle round-trip asymmetry

The comment just above this block says duration clamping is added "so save/load is symmetric", but ...rest passes center, zoom, pitch, and bearing through with their original full-precision values. parseKeyframe then rounds them on load (roundTo(center[0], 6), roundTo(zoom, 3), roundTo(pitch, 1), roundTo(bearing, 1)).

In practice the live map values are already limited in precision and the rounding is sub-perceptible, so this won't cause visible drift. But if the consistency guarantee ever matters (e.g. a test that asserts exact round-trip equality for high-precision synthetic values), the saved file will pass the test and the loaded values will differ by rounding.

If you want full symmetry, apply the same normalisation on write:

keyframes: keyframes.map(({ id: _id, durationMs, center, zoom, pitch, bearing }) => ({
  center: [roundTo(center[0], 6), roundTo(center[1], 6)] as [number, number],
  zoom: roundTo(clampNumber(zoom, 0, MAX_ZOOM), 3),
  pitch: roundTo(clampNumber(pitch, 0, MAX_PITCH), 1),
  bearing: roundTo(normalizeBearing(bearing), 1),
  durationMs: clampNumber(Math.round(durationMs), MIN_SEGMENT_SECONDS * 1000, MAX_SEGMENT_SECONDS * 1000),
})),

/** The on-disk shape of a saved tour configuration. */
export interface TourConfig {
type: string;
version: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality — TourConfig.version declared required but parsed as optional

The interface declares version: number (required), but parseTourConfig explicitly accepts files with no version field and the test "accepts a file with no version field (legacy / hand-written)" exercises that path. The on-disk type should reflect the actual accepted shape:

Suggested change
version: number;
version?: number;

Without the ?, any code that constructs a TourConfig object without version would get a TypeScript error even though the parser deliberately allows it.

};

// Load a previously saved tour setup, replacing the current keyframe list and
// frame rate. Fresh ids are minted so reloaded rows never collide. A bad file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (low confidence): window.confirm shows a native OS dialog that may be suppressed in some contexts

window.confirm is synchronous and displays the browser/OS native dialog, which can be blocked in cross-origin iframes or certain embedded contexts (e.g. some Jupyter environments). It also does not respect the app's design system or dark theme.

The existing codebase's tauri-io.ts already provides ask() from @tauri-apps/plugin-dialog (for Tauri) and presumably a fallback, which would give a better-integrated confirmation. Alternatively, an inline "confirm" banner (e.g. a yellow warning that appears above the button row when keyframes exist, with a "Load anyway" secondary button) would avoid the modal entirely.

This is a deliberate tradeoff and the current implementation is safe — just flagging it for the design review pass.

@@ -92,6 +104,9 @@ export function RecordTourDialog({
const [error, setError] = useState<string | null>(null);
const [savedName, setSavedName] = useState<string | null>(null);
const [saveCancelled, setSaveCancelled] = useState(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: comment overstates the separation guarantee

The comment says the config banner is "kept separate from the video save banner so the two messages never clobber each other". In fact they can coexist in the DOM simultaneously (good), but both are erased by clearResultMessages() whenever any new operation starts — so a "Saved setup as…" banner does disappear if the user immediately starts a recording.

The comment would be more accurate as: "kept separate so both can display simultaneously; cleared together when any new operation begins."

This is cosmetic only.

Comment on lines +394 to +395
clearResultMessages();
setConfigMessage(t("recordTour.configSaved", { name }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug — clearResultMessages() wipes the "Video saved as…" banner on config save

clearResultMessages() zeros out savedName (the video-saved notification) along with the config message and any error. Saving the setup doesn't change the tour, so the docstring on clearResultMessages says it should only be called by edits that actually change the tour — this call is outside that contract.

A user who records a tour, saves the video ("Saved as map-tour.webm"), and then immediately clicks "Save setup" will see the video-saved banner silently disappear even though nothing in the tour changed.

The fix is to clear only the states that are directly relevant here (the previous config banner and any lingering load/save error) while leaving savedName intact:

Suggested change
clearResultMessages();
setConfigMessage(t("recordTour.configSaved", { name }));
setError(null);
setConfigMessage(t("recordTour.configSaved", { name }));

(The new setConfigMessage(...) call already replaces the old config message, so there's no need to explicitly null it first.)

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Overall this is a well-structured feature: the serialization/parsing logic is solid, the bounds are shared between the UI and the parser, the file size and keyframe-count guards prevent DoS from crafted inputs, and the test suite covers the important edge cases (round-trip, clamping, version gating, malformed input). Two findings worth addressing are below.


Bugs

# Finding Confidence
1 clearResultMessages() in handleSaveConfig clears the "Video saved as…" banner — saving the config setup does not change the tour, but clearResultMessages() zeros out savedName too. A user who records, saves the video, then exports the setup will see the video-saved notification vanish without any tour edit. See inline comment on RecordTourDialog.tsx:394. High

Quality

# Finding Confidence
2 TourConfig.version declared as required (number) but parsed as optionalparseTourConfig explicitly accepts files with no version field (documented and tested as "legacy / hand-written"), so the interface should be version?: number to match the actual accepted shape. See inline comment on tour-recorder.ts:123. High

What I checked and found clean

  • Security: MAX_CONFIG_TEXT_LENGTH guards against pathological JSON before allocation; MAX_KEYFRAMES caps the loop; camera values (zoom, pitch, bearing, lat) are all clamped/normalized; error messages with internal detail are never surfaced to the user.
  • tauri-io.ts cancel fix: Adding the cancel event listener on the hidden <input> is correct — without it, dismissing the file picker in the browser would leave the Promise pending forever, permanently stalling the Load button. (Note: cancel on <input type="file"> requires Chrome 113+/Firefox 91+/Safari 17.4+; on older browsers the Promise still hangs, but that's a pre-existing limitation and the fix is the right modern approach.)
  • parseTourConfig version logic: Accepting absent version and rejecting any version > TOUR_CONFIG_VERSION or non-numeric version is the correct forward-incompatibility guard.
  • normalizeBearing: The ((b % 360) + 360) % 360 idiom handles negative inputs correctly; the boundary at 180 (maps to 180, not −180) is consistent with the documented (-180, 180] range.
  • i18n: All new user-facing strings go through t() and are defined in en.json (the source of truth). The plural forms (configLoaded_one / configLoaded_other) are correct.
  • fpsText sync on load: Both setFps and setFpsText are updated together after loading a config, so the text mirror stays in sync.
  • CLAUDE.md: No conventions violated. window.confirm is the established pattern in this codebase for lightweight destructive-action guards.

- Tighten the version gate: a present version must be an integer in
  [1, TOUR_CONFIG_VERSION], so an unrecognized 0 or negative value is now
  rejected too (only a missing version still defaults to v1). Add a test for
  the version-below-1 case.
Comment on lines +167 to +177
// Drop the id; clamp the duration on write too (mirroring parseKeyframe) so
// save/load is symmetric and a programmatic caller can't persist an
// out-of-range value.
keyframes: keyframes.map(({ id: _id, durationMs, ...rest }) => ({
...rest,
durationMs: clampNumber(
Math.round(durationMs),
MIN_SEGMENT_SECONDS * 1000,
MAX_SEGMENT_SECONDS * 1000,
),
})),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment claims the write-time clamping prevents a "programmatic caller can't persist an out-of-range value," but only durationMs is clamped here — zoom, pitch, and bearing are spread verbatim via ...rest. A programmatic caller supplying zoom: 200 would write that to disk; on re-read parseKeyframe would clamp it to 24, so the round-trip is silent-lossy for camera values. The stated symmetry goal also isn't fully met: parse(serialize(kf))kf if the input already carries rounding artefacts (e.g. zoom: 12.5000001), because the parser runs roundTo but the serializer doesn't.

In practice the UI only creates keyframes from MapLibre's live camera, so values are always in range — low-severity day-to-day — but applying the same normalization on write would make the guarantee watertight:

Suggested change
// Drop the id; clamp the duration on write too (mirroring parseKeyframe) so
// save/load is symmetric and a programmatic caller can't persist an
// out-of-range value.
keyframes: keyframes.map(({ id: _id, durationMs, ...rest }) => ({
...rest,
durationMs: clampNumber(
Math.round(durationMs),
MIN_SEGMENT_SECONDS * 1000,
MAX_SEGMENT_SECONDS * 1000,
),
})),
// Drop the id; clamp and normalize on write too (mirroring parseKeyframe) so
// save/load is symmetric and a programmatic caller can't persist an
// out-of-range value.
keyframes: keyframes.map(({ id: _id, durationMs, ...rest }) => ({
center: [roundTo(rest.center[0], 6), roundTo(rest.center[1], 6)] as [number, number],
zoom: roundTo(clampNumber(rest.zoom, 0, MAX_ZOOM), 3),
pitch: roundTo(clampNumber(rest.pitch, 0, MAX_PITCH), 1),
bearing: roundTo(normalizeBearing(rest.bearing), 1),
durationMs: clampNumber(
Math.round(durationMs),
MIN_SEGMENT_SECONDS * 1000,
MAX_SEGMENT_SECONDS * 1000,
),
})),

const handleLoadConfig = async () => {
// Loading replaces the whole tour, so confirm first when there is existing
// work a misclick would otherwise wipe.
if (keyframes.length > 0 && !window.confirm(t("recordTour.confirmLoad"))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window.confirm() is suppressed in cross-origin iframes in Chrome 92+ and modern Firefox — it returns false without ever showing a dialog. The app is embedded in Jupyter notebooks (per CLAUDE.md), where the widget iframe is cross-origin, so any user who has added even one keyframe inside a Jupyter cell would find the Load button silently broken: the confirm call returns false, the function returns early, and no file picker opens with no explanation shown.

The existing window.confirm calls in DesktopShell.tsx and StoryMapPanel.tsx follow the same pattern, so this is consistent with the codebase — but RecordTourDialog is exposed in the Jupyter embed while those files arguably aren't. Consider replacing with a small non-blocking in-app confirm using an existing shadcn AlertDialog, or at minimum falling through to the file picker when window.confirm is not available:

Suggested change
if (keyframes.length > 0 && !window.confirm(t("recordTour.confirmLoad"))) {
if (keyframes.length > 0) {
// window.confirm is suppressed in cross-origin iframes (e.g. Jupyter).
// When it returns false and was actually suppressed (not explicitly
// cancelled), we'd silently block the load — fall through instead.
const dialogAvailable =
typeof window.confirm === "function" &&
!window.top?.location.origin !== window.location.origin; // cross-origin
if (dialogAvailable && !window.confirm(t("recordTour.confirmLoad"))) {
return;
}
}

(The iframe-detection heuristic is imperfect; a proper shadcn AlertDialog is the cleaner fix, but the fallback above would at least stop silently blocking the action.)

};
// Resolve (rather than hang) when the dialog is dismissed without a pick;
// `change` never fires on cancel, so without this the Promise never settles.
input.addEventListener("cancel", () => resolve(null));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix for the hanging-promise bug. Worth noting: the cancel event on <input type="file"> shipped in Chrome 113, Firefox 113, and Safari 16.4 (all April–September 2023). On older browsers the cancel event never fires and the promise still won't settle on dismiss. If pre-2023 browser support is in scope, a complementary focus / visibilitychange heuristic would be needed. Not a blocker — this is strictly an improvement over the status quo.

throw new Error("File is not a GeoLibre tour configuration.");
}
// Reject a file written by a newer, incompatible format so its data isn't
// silently misread. A missing version is accepted (hand-written/legacy files

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nits (group):

  1. num(kf.bearing) uses fallback = 0 (north), which is a reasonable choice for a missing bearing, but is worth a brief comment since 0 for bearing is not as obvious a default as it is for zoom/pitch.

  2. The as number cast on center[0] and center[1] at line 234 is safe because Number.isFinite already guards the path — fine as-is, just noting the implicit assumption for future readers.

  3. The TourConfig interface (type: string) is slightly looser than what the parser enforces (type === TOUR_CONFIG_TYPE). Using type: typeof TOUR_CONFIG_TYPE or a literal type would let TypeScript catch a mis-spelled marker at call sites, though in practice serializeTourConfig is the only author.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

Finding Confidence
serializeTourConfig does not clamp/normalize camera fields on write. zoom, pitch, and bearing are spread verbatim via ...rest; only durationMs is clamped. The comment in that block explicitly says a "programmatic caller can't persist an out-of-range value," but that claim is false for camera fields. In the UI-only path this is harmless (MapLibre's live camera is always in range), but the guarantee stated in the comment is not delivered. Suggested fix posted as an inline comment. Medium
window.confirm is suppressed in cross-origin iframes. Chrome 92+ and modern Firefox silently return false in cross-origin iframes without showing a dialog. The Jupyter widget embed runs the app in exactly this context (per CLAUDE.md). A user in a notebook cell with one or more existing keyframes would click Load, see nothing happen, and get no error — the confirm returns false and the handler returns early. window.confirm is an established pattern in DesktopShell.tsx and others, but those panels are less likely to be active inside the Jupyter embed. See inline comment on RecordTourDialog.tsx:410. Medium

Performance

Nothing to raise.

Security

Nothing to raise. The size guard (MAX_CONFIG_TEXT_LENGTH) before JSON.parse and the keyframe-count cap (MAX_KEYFRAMES) are good defensive choices.

Quality

Finding Confidence
input.addEventListener('cancel', …) requires browser >= 2023. The cancel event for <input type="file"> shipped in Chrome 113, Firefox 113, and Safari 16.4 (all 2023). On older browsers the fix has no effect and the Promise still never settles on dismiss. This is an improvement but not a complete fix. See inline comment on tauri-io.ts:1123. High
Minor nits on parseKeyframe: default bearing fallback of 0 could use a brief comment; TourConfig.type could be typed as typeof TOUR_CONFIG_TYPE for a tighter interface. Grouped in a single inline thread. Low

CLAUDE.md

No violations. Constants correctly moved to tour-recorder.ts as a shared source of truth. i18n strings added to en.json with correct pluralization keys (_one/_other). saveTextFileWithFallback is used for the cross-platform save path. Test coverage is thorough: round-trip, clamping, bearing wrapping, version rejection, and the oversized-file guard are all exercised.


Overall this is well-structured work with solid validation and test coverage. The two medium-confidence bug findings are the ones most worth addressing before merge.

@github-actions

Copy link
Copy Markdown
Contributor

Code review\n\nReviewed tour-recorder.ts

@opengeos opengeos deleted a comment from github-actions Bot Jun 25, 2026
@giswqs
giswqs merged commit 2c55c31 into main Jun 25, 2026
20 checks passed
@giswqs
giswqs deleted the fix/issue-897-tour-save-load branch June 25, 2026 23:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Save and Load Configuration for Record Map Tour

1 participant